feat: use MCP SDK for recipient resolution - #13
Conversation
There was a problem hiding this comment.
Pull request overview
This PR migrates teamsctl mcp from a hand-rolled JSON-RPC implementation to the official Go MCP SDK and introduces recipient-phrase resolution so tools can address 1:1 chats, existing group chats, and named chats/channels without requiring raw conversation IDs.
Changes:
- Replace the custom stdio JSON-RPC MCP server with an SDK-based
mcp.Serverand typed tool registrations. - Add recipient-phrase resolution (1:1, group, named chat/channel) with a send-time fallback to individual 1:1 messages when a requested group chat doesn’t exist.
- Update docs/specs and add a changelog entry; update tests to use SDK in-memory transport.
Reviewed changes
Copilot reviewed 10 out of 11 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| README.md | Documents recipient-phrase behavior for get_messages / send_message. |
| internal/teamsctl/models.go | Removes JSON-RPC request/response models no longer needed with the SDK. |
| internal/teamsctl/mcp.go | Implements the SDK server, typed tools, recipient resolution, and fallback sending logic. |
| internal/teamsctl/mcp_tools.go | Deletes legacy manual tool schema/dispatch implementation. |
| internal/teamsctl/mcp_tools_test.go | Deletes tests tied to the legacy tool-schema implementation. |
| internal/teamsctl/mcp_test.go | Adds SDK-based integration tests for tool listing and auth-error behavior. |
| internal/teamsctl/conversations_test.go | Adds unit tests for recipient parsing/intents and group matching. |
| go.mod | Adds the MCP Go SDK and new indirect dependencies. |
| go.sum | Updates dependency checksums accordingly. |
| docs/superpowers/specs/2026-08-04-mcp-sdk-recipient-resolution-design.md | Adds a design/spec document for the migration + recipient resolution behavior. |
| CHANGELOG.md | Adds an initial changelog entry describing the new MCP SDK + recipient resolution behavior. |
Suppressed comments (3)
internal/teamsctl/mcp.go:103
- With
limitnow optional, callingFindConversations(..., input.Limit)will pass 0 when the field is omitted, returning all conversations by default. If the input is changed to*int, apply a default (e.g. 50) when nil so agents don't accidentally enumerate everything.
conversations, err := service.FindConversations(input.Query, input.Kind, input.Limit)
return nil, conversations, err
internal/teamsctl/mcp.go:32
- Same as
list_conversations: usingintforlimitmakes the default "0" (all messages) when the field is omitted, which can unintentionally fetch an unbounded history. Use*intso you can keep a safe default (e.g. 50) while still allowing0explicitly.
type messagesInput struct {
Recipient string `json:"recipient,omitempty" jsonschema:"Recipient phrase from the user, such as Mike, Mike and Charlie, ASM group chat, or ASM channel."`
ConversationID string `json:"conversation_id,omitempty" jsonschema:"Deprecated: use recipient. A Teams conversation ID remains accepted."`
Limit int `json:"limit,omitempty" jsonschema:"Maximum number of messages to return; zero returns all."`
}
internal/teamsctl/mcp.go:136
service.Messages(..., input.Limit)will pass 0 whenlimitis omitted, which returns the full message history by default. Iflimitbecomes*int, apply a default (e.g. 50) when nil so the default stays bounded.
messages, err := service.Messages(target.IDs, target.Name, input.Limit)
return nil, messages, err
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if err != nil { | ||
| return nil, nil, err | ||
| } | ||
| conversation, err := service.findOneOnOneConversation(input.Query) |
There was a problem hiding this comment.
Fixed in 006a285. Empty or whitespace-only queries now return query is required before any chat lookup.
| func looksLikeConversationID(target string) bool { | ||
| return strings.ContainsAny(target, ":@,") | ||
| } |
There was a problem hiding this comment.
Fixed in 006a285. IDs must now be comma-separated 19: or 48: Teams conversation IDs; email addresses resolve as recipient names.
| type listConversationsInput struct { | ||
| Query string `json:"query,omitempty" jsonschema:"Case-insensitive title or team-name substring."` | ||
| Kind string `json:"kind,omitempty" jsonschema:"Conversation kind: chat or channel."` | ||
| Limit int `json:"limit,omitempty" jsonschema:"Maximum number of conversations to return; zero returns all."` | ||
| } |
There was a problem hiding this comment.
Fixed in 006a285. limit is now optional (*int): omitted defaults to 50, while explicit 0 still requests all results.
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 10 out of 11 changed files in this pull request and generated no new comments.
Suppressed comments (3)
internal/teamsctl/mcp.go:201
- Recipient resolution intentionally returns a
missingGroupChatErrorfor multi-person reads when no existing group chat is found. There is currently no test asserting thatget_messagesreturns this clear error (and does not fall back to multiple 1:1 conversations). Adding a test for this behavior would align with the documented contract.
if recipients := splitRecipientNames(target); len(recipients) > 1 {
conversation, err := s.findGroupConversation(recipients)
if err != nil {
return conversationTarget{}, err
}
if len(conversation.IDs) == 0 {
return conversationTarget{}, &missingGroupChatError{Recipients: recipients}
}
internal/teamsctl/mcp.go:158
- The new missing-group fallback behavior in
sendMessage(resolving individuals and sending N separate messages) is not covered by tests. Adding an integration/unit test for themissingGroupChatErrorpath would help prevent regressions in recipient resolution and ensure the fallback reporting fields stay stable.
This issue also appears on line 194 of the same file.
target, err := service.resolveConversationTarget(firstNonEmpty(input.Recipient, input.ConversationID))
if err != nil {
var missingGroup *missingGroupChatError
if !errors.As(err, &missingGroup) {
return nil, nil, err
}
target, err = service.resolveIndividualTargets(missingGroup.Recipients)
if err != nil {
return nil, nil, err
}
target.FallbackToOneOnOne = true
}
docs/superpowers/specs/2026-08-04-mcp-sdk-recipient-resolution-design.md:13
- The design spec says the MCP server runs through
mcp.StdioTransport, but the implementation usesmcp.IOTransportinRunMCP. Update the spec to match the actual transport to avoid future drift and confusion.
`teamsctl mcp` will construct an SDK `mcp.Server`, register typed tools, and
run it through `mcp.StdioTransport`. The SDK owns initialization, protocol
negotiation, JSON-RPC framing, input schema generation, and tool dispatch.
Summary
Test